C#网络编程

Socket编程基础

作者:陈广 日期:2018-3-23


什么是 Socket

Socket 的英文意思是“插座”。作为通信机制,被翻译为“套接字”。Socket 是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。

可以把计算机间的通信理解为快递运输,Socket 就是快递小哥,你要邮寄货物给张三,只需把货物交给快递小哥,并填好收件地址、姓名、电话。过几天,张三就能从快递小哥那收到货物了。至于说运输过程中发生了什么,是空运、火车运、汽车运输、还是火箭发射过去的,经过了哪些地方,在哪些地方中转,这些你统统都不需要知道。你只需要知道,你的货物能原封不动地送到张三那就可以了。快递小哥(Socket)就是你和其他人进行通信的一个接口。

Socket 相关概念

  • IP地址:IP地址好比快递单上的通信地址,单位名称,通过这个IP地址就可以找到张三所在的单位。现在IP地址有IPv4和IPv6两种形式,我们现在使用的是IPv4,但我们国家现在正在筹建IPv6根服务器,相信不远的将来就可以用上IPv6了。
  • 端口:好比快递单上的收件人姓名,寄件时光写单位名称是不行的,一个单位里有好多人,你寄给谁?一个电脑里也可能运行有好多的网络通信程序,每个程序都有自己的通信端口,只有通过端口号才知道你的数据是发给哪个应用程序的。
  • TCP:通信协议的一种,也是互联网上最常用的通信协议,很多协议都是在它的基础上开发的。我们只需要知道 TCP 协议是一种面向连接的、可靠的传输层通信协议。所谓可靠的传输协议是指每次向对方传输数据,都需要对方应答是否已收到此数据,如果未收到则可以重传。
  • UDP:是一种简单的、面向数据报的无连接的协议,提供的是不一定可靠的传输服务。所谓“无连接”是指在正式通信前不必与对方先建立连接,不管对方状态如何都直接发送过去。这与发手机短信非常相似,只要知道对方的手机号就可以了,不要考虑对方手机处于什么状态。UDP虽然不能保证数据传输的可靠性,但数据传输的效率较高。

建立连接

下面我们来演示两台电脑是如何通过 Socket 建立连接的。两个 Socket 要建立连接,首先要区分服务器和客户端,服务器 Socket 被动接受连接,客户端 Socket 主动发起连接。其实是双方端口号必须相同。

服务器端程序

打开 Visual Studio,新建一个控制台应用程序,使用以下命名空间:

using System;
using System.Net;
using System.Net.Sockets;

输入如下代码:

static void Main(string[] args)
{
    IPAddress ip = IPAddress.Parse("127.0.0.1");
    IPEndPoint point = new IPEndPoint(ip, 5000);
    Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    try
    {
        s.Bind(point); //将Socket绑定至指定IP和端口
        s.Listen(5); //开始侦听,参数5表示最大连接数
        Console.WriteLine("服务器开始侦听...");
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
    }
    Console.ReadLine();
}

上述代码中,IPAddress表示一个IP地址,这里需要注意,使用"127.0.0.1"只是为了测试方便,而且客户端和服务器都必须在一台机子上才能使用。对外通信需要使用本机真正的 IP 地址。可使用以下代码获取本机 IPv4 地址:

string name = Dns.GetHostName();//获取本机的主机名称
IPAddress[] ipList = Dns.GetHostAddresses(name);//根据本机主机名获取所有IP地址
foreach(IPAddress ip in ipList)
{   //将IPv4地址过滤出来
    if(ip.AddressFamily==AddressFamily.InterNetwork)
    {
        Console.WriteLine(ip.ToString());
    }
}
Console.ReadLine();

Win7之后,本机也配置了 IPv6 地址,所以从主机名获取 IP 地址中会包含多个结果,需要手动过滤,方能找到相应的 IPv4 地址。AddressFamily.InterNetwork表示 IPv4 地址;AddressFamily.InterNetworkV6则表示 IPv6 地址。如果本机有多个网卡,那获取正在使用的 IP 地址就更麻烦了,这里不做介绍,有需要的自行百度。

IPEndPoint表示一个网络端点,包含了 IP 地址和端口号,可以把它理解为将 IP 地址和端口号包装在一起的这么一个类。

Socket 构造函数参数:

  • AddressFamily.InterNetwork:指定了 Socket 使用的是 IPv4 地址
  • SocketType.Stream:指定使用字节流方式进行传输
  • ProtocolType.Tcp:表示使用的是 TCP 协议

客户端程序

新开一个 Visual Studio,新建一个控制台应用程序,使用以下命名空间:

using System;
using System.Net;
using System.Net.Sockets;

输入如下代码:

static void Main(string[] args)
{
    //获取服务器端IP地址
    IPAddress ip = IPAddress.Parse("127.0.0.1");
    try
    {
        Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        s.Connect(ip, 5000); //向服务器发起连接
        Console.WriteLine("开始连接服务器 {0} ...", ip.ToString());
        if(s.Connected)
        {
            Console.WriteLine("连接成功!");
        }
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
    }
    Console.ReadLine();
}

需要注意,第一句获取的 IP 地址不是本机地址,而是要连接的服务器的 IP 地址。Socket 构造函数中的参数必须和服务器端创建的 Socket 中的参数一样方能连接。s.Connect表示向服务器发起连接请求,其中的端口号必须和服务器绑定的端口号一样。

首先运行服务器程序,然后运行客户端程序,效果如下图所示: 现在我们的客户端已经成功连接至服务器,接下来客户端就可以向服务器发送数据了。

数据的同步发送和接收

刚才写的程序只能实现连接的功能,下面来实现客户端发送数据和服务器接收数据的功能。

服务器端程序

在上述代码的基础上修改服务器代码,使用以下命名空间:

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

代码更改如下:

IPAddress ip = IPAddress.Parse("127.0.0.1");
IPEndPoint point = new IPEndPoint(ip, 5000);
Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
    s.Bind(point);
    s.Listen(5);
    Console.WriteLine("服务器开始侦听...");

    Socket recvSocket = s.Accept(); //阻塞程序并等待来自客户端的连接
    Console.WriteLine("获取一个来自{0}的连接", recvSocket.RemoteEndPoint.ToString());

    byte[] recvBuff = new byte[1024]; //创建一个接收缓冲区
    int count = recvSocket.Receive(recvBuff, recvBuff.Length, SocketFlags.None); //获取接收到的数据的长度
    string recvStr = Encoding.Unicode.GetString(recvBuff, 0, count); //将接收到的二进制流转化为字符串
    Console.WriteLine("接收到数据:{0}", recvStr);
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}
Console.ReadLine();

我们来讨论新添加的代码,s.Accept()会阻塞程序,等待客户端的连接,当有客户端发起连接请求时,会在服务器专门创建一个 Socket 用于与此客户收发数据,并随机分配一个端口号。然后程序继续往下执行。

recvSocket.Receive方法用于等待客户端发送数据,它依然会阻塞程序。当接收到客户端发送请求后,会将客户端发送的数据存入接收缓冲区recvBuff。缓冲区长度可根据需要自行决定。此方法返回接收到的数据的长度。最后程序将接收到的数据从二进制流转化为字符串并打印出来。

客户端程序

下面添加客户端发送数据的代码,使用以下命名空间:

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;

代码更改如下:

IPAddress ip = IPAddress.Parse("127.0.0.1");
try
{
    Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    s.Connect(ip, 5000); //向服务器发起连接
    Console.WriteLine("开始连接服务器 {0} ...", ip.ToString());
    if(s.Connected)
    {
        Console.WriteLine("连接成功!开始发送数据...");
    }
    string sendStr = "HI!这是一个 socket 测试!";
    byte[] sendBuff = Encoding.Unicode.GetBytes(sendStr);//创建发送缓冲
    s.Send(sendBuff, sendBuff.Length, SocketFlags.None);//发送
}
catch(Exception e)
{
    Console.WriteLine(e.Message);
}
Console.ReadLine();

客户端代码就清爽多了,这也正常,一个服务器可能会收到成百上千个客户端的连接,每个连接都要为其创建专门的 Socket,要协调这么多的 Socket,当然复杂。对客户端来说服务器只有一个,处理好与它的关系就行了。这段代码也没啥可讲的,就是在发送前,需要将发送的数据转化为字节流(字节数组)。

首先运行服务器程序,然后运行客户端程序,效果如下图所示: 注意,程序只能用一次,也就是说服务器仅能接收一次数据。这个问题后面的文章会解决。

服务器向客户端返回数据

之前讲了客户端向服务器端发送数据,服务器接收数据。接下来讲服务器接收到数据后向客户端返回数据,客户端接收。先来看看服务器和客户端的通信流程: 服务器端的侦听 Socket在接收到新的连接后,会生成一个新的 Socket 专门与此客户端通信,这时新生成的 Socket 的就和客户端的 Socket 完全一样了,它的使用方法也可以参照客户端 Socket。

服务器程序

继续在上例的基础上修改代码

IPAddress ip = IPAddress.Parse("127.0.0.1");
IPEndPoint point = new IPEndPoint(ip, 5000);
Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
    s.Bind(point);
    s.Listen(5);
    Console.WriteLine("服务器开始侦听...");

    Socket recvSocket = s.Accept(); //阻塞程序并等待来自客户端的连接
    Console.WriteLine("获取一个来自{0}的连接", recvSocket.RemoteEndPoint.ToString());

    byte[] recvBuff = new byte[1024]; //创建一个接收缓冲区
    int count = recvSocket.Receive(recvBuff, recvBuff.Length, SocketFlags.None); //获取接收到的数据的长度
    string recvStr = Encoding.Unicode.GetString(recvBuff, 0, count); //将接收到的二进制流转化为字符串
    Console.WriteLine("接收到数据:{0}", recvStr);

    //向客户端发送字符串
    Console.WriteLine("向客户端返回数据...");
    string sendStr = "服务器已经接收到信息!";
    byte[] sendBuff = Encoding.Unicode.GetBytes(sendStr);
    recvSocket.Send(sendBuff, sendBuff.Length, SocketFlags.None);
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}
Console.ReadLine();

我们看到,向客户端发送数据的代码和客户端向服务器发送数据的代码完全一样。

客户端程序

IPAddress ip = IPAddress.Parse("127.0.0.1");
try
{
    Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    s.Connect(ip, 5000); //向服务器发起连接
    Console.WriteLine("开始连接服务器 {0} ...", ip.ToString());
    if(s.Connected)
    {
        Console.WriteLine("连接成功!开始发送数据...");
    }
    string sendStr = "HI!这是一个 socket 测试!";
    byte[] sendBuff = Encoding.Unicode.GetBytes(sendStr);//创建发送缓冲
    s.Send(sendBuff, sendBuff.Length, SocketFlags.None);//发送

    //接收服务器端传来的字符串
    byte[] buff = new byte[1024];
    int count = s.Receive(buff, buff.Length, SocketFlags.None);
    string recvStr = Encoding.Unicode.GetString(buff, 0, count);
    Console.WriteLine("从服务器收到信息:{0}", recvStr);
}
catch(Exception e)
{
    Console.WriteLine(e.Message);
}
Console.ReadLine();

新添加的代码和之前服务器接收数据的代码也完全一样。

首先运行服务器程序,然后运行客户端程序,效果如下图所示: 现在双方都能收发数据了,但两端都仅能收发一次而已。

多次接收同一客户端数据

之前,服务器接收到客户端的一条数据,并回复,仅能接收一条信息而已。接下来演示服务器如何接收同一客户端的多条数据。

服务器程序

之前讨论过,Socket.Receive方法用于侦听客户端发送的数据,并且会阻塞程序,在接收到数据后解除阻塞。如果希望再次接收数据,就得再次调用Socket.Receive以继续侦听。

服务器端代码更改如下:

IPAddress ip = IPAddress.Parse("127.0.0.1");
IPEndPoint point = new IPEndPoint(ip, 5000);
Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
    s.Bind(point);
    s.Listen(5);
    Console.WriteLine("服务器开始侦听...");

    Socket recvSocket = s.Accept(); //阻塞程序并等待来自客户端的连接
    Console.WriteLine("获取一个来自{0}的连接", recvSocket.RemoteEndPoint.ToString());
    byte[] recvBuff = new byte[1024]; //创建一个接收缓冲区
    while (true)
    {                  
        int count = recvSocket.Receive(recvBuff, recvBuff.Length, SocketFlags.None); //获取接收到的数据的长度
        string recvStr = Encoding.Unicode.GetString(recvBuff, 0, count); //将接收到的二进制流转化为字符串
        Console.WriteLine("接收到数据:{0}", recvStr);
    }
}
catch (Exception e)
{
    Console.WriteLine(e.Message);
}
Console.ReadLine();

此次修改仅是将接收代码放入while(true)循环内,使得程序可以重复调用recvSocket.Receive,以实现重复接收数据的功能。当然,每次都会阻塞程序,以等待接收字符串。

客户端程序

在代码中新加入以下命名空间:

using System.Threading;

更改代码如下:

IPAddress ip = IPAddress.Parse("127.0.0.1");
try
{
    Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    s.Connect(ip, 5000); //向服务器发起连接
    Console.WriteLine("开始连接服务器 {0} ...", ip.ToString());
    if(s.Connected)
    {
        Console.WriteLine("连接成功!开始发送数据...");
    }
    for (int i = 1; i < 10; i++)
    {
        string sendStr = "这是第" + i.ToString() + "条消息。";
        byte[] sendBuff = Encoding.Unicode.GetBytes(sendStr);//创建发送缓冲
        s.Send(sendBuff, sendBuff.Length, SocketFlags.None);//发送
        Thread.Sleep(1000);
    }
}
catch(Exception e)
{
    Console.WriteLine(e.Message);
}
Console.ReadLine();

客户端代码也仅是简单地将发送代码放入for循环中,以实现重复发送,每次发送后主线程被挂起 1 秒。

运行程序,效果如下图所示:

通过以上程序,我们熟悉了整个 Socket 的侦听、接收、发送流程。但程序还有很多问题,如服务器侦听到一个连接后,就不能再做其它事情了,甚至无法再侦听第二个连接。客户端可以多次发送数据,但无法期间是无法接收数据的。要解决这些问题,就需要使用多线程编程了。这些问题我们留待以后的文章解决。

;

© 2018 - IOT小分队文章发布系统 v0.3